/**
 * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information regarding copyright ownership. Apereo
 * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
 * this file except in compliance with the License. You may obtain a copy of the License at the
 * following location:
 *
 * <p>http://www.apache.org/licenses/LICENSE-2.0
 *
 * <p>Unless required by applicable law or agreed to in writing, software distributed under the
 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apereo.portal.groups.ldap;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.List;
import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.BasicAttributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.xml.parsers.ParserConfigurationException;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apereo.portal.EntityIdentifier;
import org.apereo.portal.ResourceMissingException;
import org.apereo.portal.groups.EntityGroupImpl;
import org.apereo.portal.groups.EntityImpl;
import org.apereo.portal.groups.GroupsException;
import org.apereo.portal.groups.IEntity;
import org.apereo.portal.groups.IEntityGroup;
import org.apereo.portal.groups.IEntityGroupStore;
import org.apereo.portal.groups.IEntitySearcher;
import org.apereo.portal.groups.IEntityStore;
import org.apereo.portal.groups.IGroupMember;
import org.apereo.portal.groups.ILockableEntityGroup;
import org.apereo.portal.security.IPerson;
import org.apereo.portal.spring.locator.EntityTypesLocator;
import org.apereo.portal.utils.ResourceLoader;
import org.apereo.portal.utils.SmartCache;
import org.springframework.ldap.support.LdapEncoder;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.Text;
import org.xml.sax.SAXException;

/** LDAPGroupStore. */
public class LDAPGroupStore implements IEntityGroupStore, IEntityStore, IEntitySearcher {
    private static final Log log = LogFactory.getLog(LDAPGroupStore.class);
    protected String url;
    protected String logonid;
    protected String logonpassword;
    protected String keyfield;
    protected String namefield;
    protected String usercontext = "";
    protected HashMap groups;
    protected SmartCache contexts;
    protected SmartCache personkeys;
    protected static Class iperson = IPerson.class;
    protected static Class group = IEntityGroup.class;
    protected static short ELEMENT_NODE = Node.ELEMENT_NODE;

    public LDAPGroupStore() {
        Document config = null;
        try {
            config =
                    ResourceLoader.getResourceAsDocument(
                            this.getClass(), "/properties/groups/LDAPGroupStoreConfig.xml", true);
        } catch (IOException e) {
            throw new RuntimeException(
                    "LDAPGroupStore: Unable to find configuration configuration document", e);
        } catch (ResourceMissingException e) {
            throw new RuntimeException(
                    "LDAPGroupStore: Unable to find configuration configuration document", e);
        } catch (ParserConfigurationException e) {
            throw new RuntimeException(
                    "LDAPGroupStore: Unable to parse configuration configuration document", e);
        } catch (SAXException e) {
            throw new RuntimeException(
                    "LDAPGroupStore: Unable to parse configuration configuration document", e);
        }
        init(config);
    }

    public LDAPGroupStore(Document config) {
        init(config);
    }

    protected void init(Document config) {
        this.groups = new HashMap();
        this.contexts = new SmartCache(120);
        config.normalize();
        int refreshminutes = 120;
        Element root = config.getDocumentElement();
        NodeList nl = root.getElementsByTagName("config");
        if (nl.getLength() == 1) {
            Element conf = (Element) nl.item(0);
            Node cc = conf.getFirstChild();
            // NodeList cl= conf.getF.getChildNodes();
            // for(int i=0; i<cl.getLength(); i++){
            while (cc != null) {
                if (cc.getNodeType() == ELEMENT_NODE) {
                    Element c = (Element) cc;
                    c.normalize();
                    Node t = c.getFirstChild();
                    if (t != null && t.getNodeType() == Node.TEXT_NODE) {
                        String name = c.getNodeName();
                        String text = ((Text) t).getData();
                        // System.out.println(name+" = "+text);
                        if (name.equals("url")) {
                            url = text;
                        } else if (name.equals("logonid")) {
                            logonid = text;
                        } else if (name.equals("logonpassword")) {
                            logonpassword = text;
                        } else if (name.equals("keyfield")) {
                            keyfield = text;
                        } else if (name.equals("namefield")) {
                            namefield = text;
                        } else if (name.equals("usercontext")) {
                            usercontext = text;
                        } else if (name.equals("refresh-minutes")) {
                            try {
                                refreshminutes = Integer.parseInt(text);
                            } catch (Exception e) {
                            }
                        }
                    }
                }
                cc = cc.getNextSibling();
            }
        } else {
            throw new RuntimeException(
                    "LDAPGroupStore: config file must contain one config element");
        }

        this.personkeys = new SmartCache(refreshminutes * 60);

        NodeList gl = root.getChildNodes();
        for (int j = 0; j < gl.getLength(); j++) {
            if (gl.item(j).getNodeType() == ELEMENT_NODE) {
                Element g = (Element) gl.item(j);
                if (g.getNodeName().equals("group")) {
                    GroupShadow shadow = processXmlGroupRecursive(g);
                    groups.put(shadow.key, shadow);
                }
            }
        }
    }

    protected String[] getPersonKeys(String groupKey) {
        String[] r = (String[]) personkeys.get(groupKey);
        if (r == null) {
            GroupShadow shadow = (GroupShadow) groups.get(groupKey);
            if (shadow.entities != null) {
                r = shadow.entities.getPersonKeys();
            } else {
                r = new String[0];
            }
            personkeys.put(groupKey, r);
        }
        return r;
    }

    protected GroupShadow processXmlGroupRecursive(Element groupElem) {
        GroupShadow shadow = new GroupShadow();
        shadow.key = groupElem.getAttribute("key");
        shadow.name = groupElem.getAttribute("name");
        // System.out.println("Loading configuration for group "+shadow.name);
        ArrayList subgroups = new ArrayList();
        NodeList nl = groupElem.getChildNodes();
        for (int i = 0; i < nl.getLength(); i++) {
            if (nl.item(i).getNodeType() == ELEMENT_NODE) {
                Element e = (Element) nl.item(i);
                if (e.getNodeName().equals("group")) {
                    GroupShadow sub = processXmlGroupRecursive(e);
                    subgroups.add(sub);
                    groups.put(sub.key, sub);
                } else if (e.getNodeName().equals("entity-set")) {
                    shadow.entities = new EntitySet(e);
                } else if (e.getNodeName().equals("description")) {
                    e.normalize();
                    Text t = (Text) e.getFirstChild();
                    if (t != null) {
                        shadow.description = t.getData();
                    }
                }
            }
        }
        shadow.subgroups = (GroupShadow[]) subgroups.toArray(new GroupShadow[0]);
        return shadow;
    }

    protected class GroupShadow {
        protected String key;
        protected String name;
        protected String description;
        protected GroupShadow[] subgroups;
        protected EntitySet entities;
    }

    protected class EntitySet {
        public static final int FILTER = 1;
        public static final int UNION = 2;
        public static final int DIFFERENCE = 3;
        public static final int INTERSECTION = 4;
        public static final int SUBTRACT = 5;
        public static final int ATTRIBUTES = 6;

        protected int type;
        protected String filter;
        protected Attributes attributes;
        protected EntitySet[] subsets;

        protected EntitySet(Element entityset) {
            entityset.normalize();
            Node n = entityset.getFirstChild();
            while (n.getNodeType() != Node.ELEMENT_NODE) {
                n = n.getNextSibling();
            }
            Element e = (Element) n;
            String type = e.getNodeName();
            boolean collectSubsets = false;
            if (type.equals("filter")) {
                this.type = FILTER;
                filter = e.getAttribute("string");
            } else if (type.equals("attributes")) {
                this.type = ATTRIBUTES;
                attributes = new BasicAttributes();
                NodeList atts = e.getChildNodes();
                for (int i = 0; i < atts.getLength(); i++) {
                    if (atts.item(i).getNodeType() == ELEMENT_NODE) {
                        Element a = (Element) atts.item(i);
                        attributes.put(a.getAttribute("name"), a.getAttribute("value"));
                    }
                }
            } else if (type.equals("union")) {
                this.type = UNION;
                collectSubsets = true;
            } else if (type.equals("intersection")) {
                this.type = INTERSECTION;
                collectSubsets = true;
            } else if (type.equals("difference")) {
                this.type = DIFFERENCE;
                collectSubsets = true;
            } else if (type.equals("subtract")) {
                this.type = SUBTRACT;
                collectSubsets = true;
            }

            if (collectSubsets) {
                ArrayList subs = new ArrayList();
                NodeList nl = e.getChildNodes();
                for (int i = 0; i < nl.getLength(); i++) {
                    if (nl.item(i).getNodeType() == Node.ELEMENT_NODE) {
                        EntitySet subset = new EntitySet((Element) nl.item(i));
                        subs.add(subset);
                    }
                }
                subsets = (EntitySet[]) subs.toArray(new EntitySet[0]);
            }
        }

        protected String[] getPersonKeys() {
            ArrayList keys = new ArrayList();
            // System.out.println("Loading keys!!");
            String[] subkeys;
            switch (type) {
                case FILTER:
                    // System.out.println("Performing ldap query!!");
                    DirContext context = getConnection();
                    NamingEnumeration userlist = null;
                    SearchControls sc = new SearchControls();
                    sc.setSearchScope(SearchControls.SUBTREE_SCOPE);
                    sc.setReturningAttributes(new String[] {keyfield});
                    try {
                        userlist = context.search(usercontext, filter, sc);
                    } catch (NamingException nex) {
                        log.error("LDAPGroupStore: Unable to perform filter " + filter, nex);
                    }
                    processLdapResults(userlist, keys);
                    break;
                case ATTRIBUTES:
                    // System.out.println("Performing ldap attribute query!!");
                    DirContext context2 = getConnection();
                    NamingEnumeration userlist2 = null;
                    try {
                        userlist2 =
                                context2.search(usercontext, attributes, new String[] {keyfield});
                    } catch (NamingException nex) {
                        log.error("LDAPGroupStore: Unable to perform attribute search", nex);
                    }
                    processLdapResults(userlist2, keys);
                    break;
                case UNION:
                    for (int i = 0; i < subsets.length; i++) {
                        subkeys = subsets[i].getPersonKeys();
                        for (int j = 0; j < subkeys.length; j++) {
                            String key = subkeys[j];
                            if (!keys.contains(key)) {
                                keys.add(key);
                            }
                        }
                    }
                    break;
                case INTERSECTION:
                    if (subsets.length > 0) {
                        // load initial keys from first entity set
                        String[] interkeys = subsets[0].getPersonKeys();
                        // now set non-recurring keys to null
                        for (int m = 1; m < subsets.length; m++) {
                            subkeys = subsets[m].getPersonKeys();
                            for (int n = 0; n < interkeys.length; n++) {
                                if (interkeys[n] != null) {
                                    boolean remove = true;
                                    for (int o = 0; o < subkeys.length; o++) {
                                        if (subkeys[o].equals(interkeys[n])) {
                                            // found a match, so far the intersection for this key
                                            // is valid
                                            remove = false;
                                            break;
                                        }
                                    }
                                    if (remove) {
                                        interkeys[n] = null;
                                    }
                                }
                            }
                        }
                        for (int p = 0; p < interkeys.length; p++) {
                            if (interkeys[p] != null) {
                                keys.add(interkeys[p]);
                            }
                        }
                    }
                    break;
                case DIFFERENCE:
                    if (subsets.length > 0) {
                        ArrayList discardKeys = new ArrayList();
                        subkeys = subsets[0].getPersonKeys();
                        // load initial keys from first entity set
                        for (int q = 0; q < subkeys.length; q++) {
                            keys.add(subkeys[q]);
                        }
                        for (int r = 1; r < subsets.length; r++) {
                            subkeys = subsets[r].getPersonKeys();
                            for (int s = 0; s < subkeys.length; s++) {
                                String ky = subkeys[s];
                                if (keys.contains(ky)) {
                                    keys.remove(ky);
                                    discardKeys.add(ky);
                                } else {
                                    if (!discardKeys.contains(ky)) {
                                        keys.add(ky);
                                    }
                                }
                            }
                        }
                    }
                    break;
                case SUBTRACT:
                    if (subsets.length > 0) {
                        subkeys = subsets[0].getPersonKeys();
                        // load initial keys from first entity set
                        for (int t = 0; t < subkeys.length; t++) {
                            keys.add(subkeys[t]);
                        }
                        for (int u = 1; u < subsets.length; u++) {
                            subkeys = subsets[u].getPersonKeys();
                            for (int v = 0; v < subkeys.length; v++) {
                                String kyy = subkeys[v];
                                if (keys.contains(kyy)) {
                                    keys.remove(kyy);
                                }
                            }
                        }
                    }
                    break;
            }
            return (String[]) keys.toArray(new String[0]);
        }
    }

    protected void processLdapResults(NamingEnumeration results, ArrayList keys) {
        // long time1 = System.currentTimeMillis();
        // long casting=0;
        // long getting=0;
        // long setting=0;
        // long looping=0;
        // long loop1=System.currentTimeMillis();
        try {
            while (results.hasMore()) {
                // long loop2 = System.currentTimeMillis();
                // long cast1=System.currentTimeMillis();
                // looping=looping+loop2-loop1;
                SearchResult result = (SearchResult) results.next();
                // long cast2 = System.currentTimeMillis();
                // long get1 = System.currentTimeMillis();
                Attributes ldapattribs = result.getAttributes();
                // long get2 = System.currentTimeMillis();
                // long set1 = System.currentTimeMillis();
                Attribute attrib = ldapattribs.get(keyfield);
                if (attrib != null) {
                    keys.add(String.valueOf(attrib.get()).toLowerCase());
                }
                // long set2 = System.currentTimeMillis();
                // loop1=System.currentTimeMillis();
                // casting=casting+cast2-cast1;
                // setting=setting+set2-set1;
                // getting=getting+get2-get1;
            }
        } catch (NamingException nex) {
            log.error("LDAPGroupStore: error processing results", nex);
        } finally {
            try {
                results.close();
            } catch (Exception e) {
            }
        }
        // long time5 = System.currentTimeMillis();
        // System.out.println("Result processing took "+(time5-time1)+": "+getting+" for getting, "
        //  +setting+" for setting, "+casting+" for casting, "+looping+" for looping,"
        //  +(time5-loop1)+" for closing");
    }

    protected DirContext getConnection() {
        // JNDI boilerplate to connect to an initial context
        DirContext context = (DirContext) contexts.get("context");
        if (context == null) {
            Hashtable jndienv = new Hashtable();
            jndienv.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
            jndienv.put(Context.SECURITY_AUTHENTICATION, "simple");
            if (url.startsWith("ldaps")) { // Handle SSL connections
                String newurl = url.substring(0, 4) + url.substring(5);
                jndienv.put(Context.SECURITY_PROTOCOL, "ssl");
                jndienv.put(Context.PROVIDER_URL, newurl);
            } else {
                jndienv.put(Context.PROVIDER_URL, url);
            }
            if (logonid != null) jndienv.put(Context.SECURITY_PRINCIPAL, logonid);
            if (logonpassword != null) jndienv.put(Context.SECURITY_CREDENTIALS, logonpassword);
            try {
                context = new InitialDirContext(jndienv);
            } catch (NamingException nex) {
                log.error("LDAPGroupStore: unable to get context", nex);
            }
            contexts.put("context", context);
        }
        return context;
    }

    protected IEntityGroup makeGroup(GroupShadow shadow) throws GroupsException {
        IEntityGroup group = null;
        if (shadow != null) {
            group = new EntityGroupImpl(shadow.key, iperson);
            group.setDescription(shadow.description);
            group.setName(shadow.name);
        }
        return group;
    }

    protected GroupShadow getShadow(IEntityGroup group) {
        return (GroupShadow) groups.get(group.getLocalKey());
    }

    @Override
    public void delete(IEntityGroup group) throws GroupsException {
        throw new java.lang.UnsupportedOperationException(
                "LDAPGroupStore: Method delete() not supported.");
    }

    @Override
    public IEntityGroup find(String key) throws GroupsException {
        return makeGroup((GroupShadow) this.groups.get(key));
    }

    @Override
    public Iterator findParentGroups(IGroupMember gm) throws GroupsException {
        ArrayList al = new ArrayList();
        String key;
        GroupShadow[] shadows = getGroupShadows();
        if (!gm.isGroup()) {
            key = gm.getKey();
            for (int i = 0; i < shadows.length; i++) {
                String[] keys = getPersonKeys(shadows[i].key);
                for (int j = 0; j < keys.length; j++) {
                    if (keys[j].equals(key)) {
                        al.add(makeGroup(shadows[i]));
                        break;
                    }
                }
            }
        }

        if (gm.isGroup()) {
            key = ((IEntityGroup) gm).getLocalKey();
            for (int i = 0; i < shadows.length; i++) {
                for (int j = 0; j < shadows[i].subgroups.length; j++) {
                    if (shadows[i].subgroups[j].key.equals(key)) {
                        al.add(makeGroup(shadows[i]));
                        break;
                    }
                }
            }
        }

        return al.iterator();
    }

    @Override
    public String[] findMemberGroupKeys(IEntityGroup group) throws GroupsException {
        List keys = new ArrayList();
        for (Iterator itr = findMemberGroups(group); itr.hasNext(); ) {
            IEntityGroup eg = (IEntityGroup) itr.next();
            keys.add(eg.getKey());
        }
        return (String[]) keys.toArray(new String[keys.size()]);
    }

    @Override
    public Iterator findMemberGroups(IEntityGroup group) throws GroupsException {
        ArrayList al = new ArrayList();
        GroupShadow shadow = getShadow(group);
        for (int i = 0; i < shadow.subgroups.length; i++) {
            al.add(makeGroup(shadow.subgroups[i]));
        }
        return al.iterator();
    }

    @Override
    public IEntityGroup newInstance(Class entityType) throws GroupsException {
        throw new java.lang.UnsupportedOperationException(
                "LDAPGroupStore: Method newInstance() not supported");
    }

    @Override
    public void update(IEntityGroup group) throws GroupsException {
        throw new java.lang.UnsupportedOperationException(
                "LDAPGroupStore: Method update() not supported");
    }

    @Override
    public void updateMembers(IEntityGroup group) throws GroupsException {
        throw new java.lang.UnsupportedOperationException(
                "LDAPGroupStore: Method updateMembers() not supported");
    }

    @Override
    public ILockableEntityGroup findLockable(String key) throws GroupsException {
        throw new java.lang.UnsupportedOperationException(
                "LDAPGroupStore: Method findLockable() not supported");
    }

    protected GroupShadow[] getGroupShadows() {
        return (GroupShadow[]) groups.values().toArray(new GroupShadow[0]);
    }

    @Override
    public EntityIdentifier[] searchForGroups(String query, SearchMethod method, Class leaftype)
            throws GroupsException {
        ArrayList ids = new ArrayList();
        GroupShadow[] g = getGroupShadows();
        int i;
        switch (method) {
            case DISCRETE:
                for (i = 0; i < g.length; i++) {
                    if (g[i].name.equals(query)) {
                        ids.add(new EntityIdentifier(g[i].key, group));
                    }
                }
                break;
            case DISCRETE_CI:
                for (i = 0; i < g.length; i++) {
                    if (g[i].name.equalsIgnoreCase(query)) {
                        ids.add(new EntityIdentifier(g[i].key, group));
                    }
                }
                break;
            case STARTS_WITH:
                for (i = 0; i < g.length; i++) {
                    if (g[i].name.startsWith(query)) {
                        ids.add(new EntityIdentifier(g[i].key, group));
                    }
                }
                break;
            case STARTS_WITH_CI:
                for (i = 0; i < g.length; i++) {
                    if (g[i].name.toUpperCase().startsWith(query.toUpperCase())) {
                        ids.add(new EntityIdentifier(g[i].key, group));
                    }
                }
                break;
            case ENDS_WITH:
                for (i = 0; i < g.length; i++) {
                    if (g[i].name.endsWith(query)) {
                        ids.add(new EntityIdentifier(g[i].key, group));
                    }
                }
                break;
            case ENDS_WITH_CI:
                for (i = 0; i < g.length; i++) {
                    if (g[i].name.toUpperCase().endsWith(query.toUpperCase())) {
                        ids.add(new EntityIdentifier(g[i].key, group));
                    }
                }
                break;
            case CONTAINS:
                for (i = 0; i < g.length; i++) {
                    if (g[i].name.indexOf(query) > -1) {
                        ids.add(new EntityIdentifier(g[i].key, group));
                    }
                }
                break;
            case CONTAINS_CI:
                for (i = 0; i < g.length; i++) {
                    if (g[i].name.toUpperCase().indexOf(query.toUpperCase()) > -1) {
                        ids.add(new EntityIdentifier(g[i].key, group));
                    }
                }
                break;
        }
        return (EntityIdentifier[]) ids.toArray(new EntityIdentifier[0]);
    }

    @Override
    public Iterator findEntitiesForGroup(IEntityGroup group) throws GroupsException {
        GroupShadow shadow = getShadow(group);
        ArrayList al = new ArrayList();
        String[] keys = getPersonKeys(shadow.key);
        for (int i = 0; i < keys.length; i++) {
            al.add(new EntityImpl(keys[i], iperson));
        }
        return al.iterator();
    }

    @Override
    public IEntity newInstance(String key, Class type) throws GroupsException {
        if (EntityTypesLocator.getEntityTypes().getEntityIDFromType(type) == null) {
            throw new GroupsException("Invalid group type: " + type);
        }
        return new EntityImpl(key, type);
    }

    @Override
    public EntityIdentifier[] searchForEntities(String query, SearchMethod method, Class type)
            throws GroupsException {
        if (type != group && type != iperson) return new EntityIdentifier[0];
        // Guarantee that LDAP injection is prevented by replacing LDAP special characters
        // with escaped versions of the character
        query = LdapEncoder.filterEncode(query);
        ArrayList ids = new ArrayList();
        switch (method) {
            case STARTS_WITH:
            case STARTS_WITH_CI:
                query = query + "*";
                break;
            case ENDS_WITH:
            case ENDS_WITH_CI:
                query = "*" + query;
                break;
            case CONTAINS:
            case CONTAINS_CI:
                query = "*" + query + "*";
                break;
            case DISCRETE:
            case DISCRETE_CI:
                // Already handled.
        }
        query = namefield + "=" + query;
        DirContext context = getConnection();
        NamingEnumeration userlist = null;
        SearchControls sc = new SearchControls();
        sc.setSearchScope(SearchControls.SUBTREE_SCOPE);
        sc.setReturningAttributes(new String[] {keyfield});
        try {
            userlist = context.search(usercontext, query, sc);
            ArrayList keys = new ArrayList();
            processLdapResults(userlist, keys);
            String[] k = (String[]) keys.toArray(new String[0]);
            for (int i = 0; i < k.length; i++) {
                ids.add(new EntityIdentifier(k[i], iperson));
            }
            return (EntityIdentifier[]) ids.toArray(new EntityIdentifier[0]);
        } catch (NamingException nex) {
            throw new GroupsException("LDAPGroupStore: Unable to perform filter " + query, nex);
        }
    }

    /**
     * Answers if <code>group</code> contains <code>member</code>.
     *
     * @return boolean
     * @param group org.apereo.portal.groups.IEntityGroup
     * @param member org.apereo.portal.groups.IGroupMember
     */
    @Override
    public boolean contains(IEntityGroup group, IGroupMember member) throws GroupsException {
        boolean found = false;
        Iterator itr = (member.isGroup()) ? findMemberGroups(group) : findEntitiesForGroup(group);
        while (itr.hasNext() && !found) {
            found = member.equals(itr.next());
        }
        return found;
    }
}
